feat(credentials) Add google service account support#3828
feat(credentials) Add google service account support#3828TheodoreSpeaks merged 27 commits intostagingfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cursor review |
|
@greptile review |
PR SummaryMedium Risk Overview Extends token and credential resolution flows to support service accounts by generating short-lived Google access tokens via JWT bearer exchange (with scope filtering and optional Updates the integrations UI and workflow editor to allow adding service accounts (paste/upload JSON), surface an Impersonated Account field only when a service-account credential is selected, and ensures credential lists/authorization checks include service-account membership rules. Adds end-user docs for setup and domain-wide delegation. Written by Cursor Bugbot for commit fac5f63. This will update automatically on new commits. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Impersonation field shown for non-service-account credentials
- The impersonation input now renders only when the selected credential is a service account by gating on
isServiceAccountinstead of provider-level support.
- The impersonation input now renders only when the selected credential is a service account by gating on
- ✅ Fixed: Return type mismatch:
undefinedinstead offalsehasExternalApiCredentialsnow coalesces the optional chaining result with?? falseso it always returns a boolean.
- ✅ Fixed: Serializer orphan logic is broader than intended
- The orphan serialization path was narrowed to only include the known
impersonateUserEmailkey instead of all orphan sub-blocks with values.
- The orphan serialization path was narrowed to only include the known
Or push these changes by commenting:
@cursor push 035b79165e
Preview (035b79165e)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -10,7 +10,6 @@
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
- getServiceAccountProviderForProviderId,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
@@ -122,11 +121,6 @@
[selectedCredential]
)
- const supportsServiceAccount = useMemo(
- () => !!getServiceAccountProviderForProviderId(effectiveProviderId),
- [effectiveProviderId]
- )
-
const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId]
@@ -377,7 +371,7 @@
className={overlayContent ? 'pl-7' : ''}
/>
- {supportsServiceAccount && !isPreview && (
+ {isServiceAccount && !isPreview && (
<div className='mt-2.5 flex flex-col gap-2.5'>
<div className='flex items-center gap-1.5 pl-0.5'>
<Label>
diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts
--- a/apps/sim/lib/auth/hybrid.ts
+++ b/apps/sim/lib/auth/hybrid.ts
@@ -25,7 +25,7 @@
export function hasExternalApiCredentials(headers: Headers): boolean {
if (headers.has(API_KEY_HEADER)) return true
const auth = headers.get('authorization')
- return auth?.startsWith(BEARER_PREFIX)
+ return auth?.startsWith(BEARER_PREFIX) ?? false
}
export interface AuthResult {
diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts
--- a/apps/sim/serializer/index.ts
+++ b/apps/sim/serializer/index.ts
@@ -347,14 +347,17 @@
)
)
- const isOrphanWithValue =
- matchingConfigs.length === 0 && subBlock.value != null && subBlock.value !== ''
+ const isImpersonateUserEmailOrphanWithValue =
+ id === 'impersonateUserEmail' &&
+ matchingConfigs.length === 0 &&
+ subBlock.value != null &&
+ subBlock.value !== ''
if (
(matchingConfigs.length > 0 && shouldInclude) ||
hasStarterInputFormatValues ||
isLegacyAgentField ||
- isOrphanWithValue
+ isImpersonateUserEmailOrphanWithValue
) {
params[id] = subBlock.value
}This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
...omponents/editor/components/sub-block/components/credential-selector/credential-selector.tsx
Outdated
Show resolved
Hide resolved
Greptile SummaryThis PR adds Google service account (domain-wide delegation) as a first-class credential type in Sim, enabling automated workflows to access Gmail, Drive, Sheets, Calendar, and other Google Workspace APIs without per-user OAuth consent flows. Key changes:
Confidence Score: 5/5Safe to merge — all remaining findings are P2 (non-blocking improvements). The core security properties are solid: service account keys are encrypted at rest, the JWT is signed server-side with the stored private key, scopes are correctly filtered, and authorization checks gate every token-exchange path. DB schema changes are additive with proper constraints. All 13 Google blocks consistently apply the service account subblocks. The only issues found are (1) a swallowed apps/sim/app/api/auth/oauth/token/route.ts — error message from ServiceAccountTokenError is swallowed; apps/sim/app/api/auth/oauth/utils.ts — no token caching. Important Files Changed
Sequence DiagramsequenceDiagram
participant UI as Workflow UI
participant ToolExec as tools/index.ts
participant TokenAPI as /api/auth/oauth/token
participant UtilsFn as getServiceAccountToken
participant DB as Database
participant GoogleToken as Google Token Endpoint
UI->>ToolExec: execute tool (credential=SA, impersonateUserEmail=alice@co)
ToolExec->>ToolExec: build tokenPayload (scopes, impersonateEmail)
ToolExec->>TokenAPI: POST /api/auth/oauth/token
TokenAPI->>DB: resolveOAuthAccountId(credentialId)
DB-->>TokenAPI: {credentialType: 'service_account', credentialId}
TokenAPI->>TokenAPI: authorizeCredentialUse()
TokenAPI->>UtilsFn: getServiceAccountToken(credentialId, scopes, impersonateEmail)
UtilsFn->>DB: SELECT encrypted_service_account_key
DB-->>UtilsFn: encrypted key
UtilsFn->>UtilsFn: decryptSecret → JSON.parse → filter scopes
UtilsFn->>UtilsFn: createSign(RS256) → JWT assertion
UtilsFn->>GoogleToken: POST grant_type=jwt-bearer
GoogleToken-->>UtilsFn: {access_token}
UtilsFn-->>TokenAPI: accessToken
TokenAPI-->>ToolExec: {accessToken}
ToolExec->>ToolExec: call Google API with accessToken
Reviews (4): Last reviewed commit: "Fix build error" | Re-trigger Greptile |
apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
Show resolved
Hide resolved
|
@BugBot review |
|
@greptile review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Reactive conditions hook only evaluates first matching subblock
- Updated
useReactiveConditionsto evaluate all subblocks withreactiveConditionand hide each one independently based on its own watched credential type.
- Updated
- ✅ Fixed: Double resolve of credential in refreshAccessTokenIfNeeded path
- Refactored account lookup into
getCredentialByAccountIdand reused the already-resolved account ID inrefreshAccessTokenIfNeededto remove the duplicate resolution query.
- Refactored account lookup into
Or push these changes by commenting:
@cursor push dafd0590a4
Preview (dafd0590a4)
diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts
--- a/apps/sim/app/api/auth/oauth/utils.test.ts
+++ b/apps/sim/app/api/auth/oauth/utils.test.ts
@@ -166,7 +166,6 @@
accountId: 'account-id',
workspaceId: 'workspace-id',
}
- const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
@@ -176,7 +175,6 @@
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
- mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -192,7 +190,6 @@
accountId: 'account-id',
workspaceId: 'workspace-id',
}
- const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -202,7 +199,6 @@
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
- mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockUpdateChain()
@@ -221,7 +217,6 @@
it('should return null if credential not found', async () => {
mockSelectChain([])
- mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
@@ -235,7 +230,6 @@
accountId: 'account-id',
workspaceId: 'workspace-id',
}
- const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -245,7 +239,6 @@
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
- mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null)
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -231,10 +231,14 @@
return undefined
}
+ return getCredentialByAccountId(requestId, resolved.accountId, userId)
+}
+
+async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
const credentials = await db
.select()
.from(account)
- .where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
+ .where(and(eq(account.id, accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
@@ -244,7 +248,7 @@
return {
...credentials[0],
- resolvedCredentialId: resolved.accountId,
+ resolvedCredentialId: accountId,
}
}
@@ -365,8 +369,7 @@
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
}
- // Get the credential directly using the getCredential helper
- const credential = await getCredential(requestId, credentialId, userId)
+ const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from 'react'
+import { useQueries } from '@tanstack/react-query'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
@@ -7,13 +8,18 @@
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
-import { useWorkspaceCredential } from '@/hooks/queries/credentials'
+import { type WorkspaceCredential, workspaceCredentialKeys } from '@/hooks/queries/credentials'
+import { fetchJson } from '@/hooks/selectors/helpers'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
+interface CredentialResponse {
+ credential?: WorkspaceCredential | null
+}
+
/**
* Evaluates reactive conditions for subblocks. Always calls the same hooks
* regardless of whether a reactive condition exists (Rules of Hooks).
@@ -26,45 +32,86 @@
activeWorkflowId: string | null,
blockSubBlockValues: Record<string, unknown>
): Set<string> {
- const reactiveSubBlock = useMemo(
- () => subBlocks.find((sb) => sb.reactiveCondition),
+ const reactiveSubBlocks = useMemo(
+ () => subBlocks.filter((subBlock) => subBlock.reactiveCondition),
[subBlocks]
)
- const reactiveCond = reactiveSubBlock?.reactiveCondition
// Subscribe to watched field values — always called (stable hook count)
- const watchedCredentialId = useSubBlockStore(
+ const watchedCredentialIdsBySubBlock = useSubBlockStore(
useCallback(
(state) => {
- if (!reactiveCond || !activeWorkflowId) return ''
+ if (!activeWorkflowId || reactiveSubBlocks.length === 0) return {}
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {}
const merged = { ...blockSubBlockValues, ...blockValues }
- for (const field of reactiveCond.watchFields) {
- const val = merged[field]
- if (val && typeof val === 'string') return val
- }
- return ''
+ return reactiveSubBlocks.reduce<Record<string, string>>((acc, subBlock) => {
+ const reactiveCondition = subBlock.reactiveCondition
+ if (!reactiveCondition) return acc
+
+ for (const field of reactiveCondition.watchFields) {
+ const value = merged[field]
+ if (value && typeof value === 'string') {
+ acc[subBlock.id] = value
+ break
+ }
+ }
+
+ return acc
+ }, {})
},
- [reactiveCond, activeWorkflowId, blockId, blockSubBlockValues]
+ [activeWorkflowId, blockId, blockSubBlockValues, reactiveSubBlocks]
)
)
- // Always call useWorkspaceCredential (stable hook count), disable when not needed
- const { data: credential } = useWorkspaceCredential(
- watchedCredentialId || undefined,
- Boolean(reactiveCond && watchedCredentialId)
+ const watchedCredentialIds = useMemo(
+ () => Array.from(new Set(Object.values(watchedCredentialIdsBySubBlock))),
+ [watchedCredentialIdsBySubBlock]
)
+ const credentialQueries = useQueries({
+ queries: watchedCredentialIds.map((credentialId) => ({
+ queryKey: workspaceCredentialKeys.detail(credentialId),
+ queryFn: async ({ signal }: { signal: AbortSignal }) => {
+ const data = await fetchJson<CredentialResponse>(`/api/credentials/${credentialId}`, {
+ signal,
+ })
+ return data.credential ?? null
+ },
+ enabled: Boolean(credentialId) && reactiveSubBlocks.length > 0,
+ staleTime: 60 * 1000,
+ })),
+ })
+
+ const credentialTypeById = useMemo(() => {
+ const typeById = new Map<string, WorkspaceCredential['type']>()
+ watchedCredentialIds.forEach((credentialId, index) => {
+ const credential = credentialQueries[index]?.data
+ if (credential?.type) {
+ typeById.set(credentialId, credential.type)
+ }
+ })
+ return typeById
+ }, [credentialQueries, watchedCredentialIds])
+
return useMemo(() => {
const hidden = new Set<string>()
- if (!reactiveSubBlock || !reactiveCond) return hidden
+ if (reactiveSubBlocks.length === 0) return hidden
- const conditionMet = credential?.type === reactiveCond.requiredType
- if (!conditionMet) {
- hidden.add(reactiveSubBlock.id)
+ for (const subBlock of reactiveSubBlocks) {
+ const reactiveCondition = subBlock.reactiveCondition
+ if (!reactiveCondition) continue
+
+ const watchedCredentialId = watchedCredentialIdsBySubBlock[subBlock.id]
+ const credentialType = watchedCredentialId
+ ? credentialTypeById.get(watchedCredentialId)
+ : undefined
+ if (credentialType !== reactiveCondition.requiredType) {
+ hidden.add(subBlock.id)
+ }
}
+
return hidden
- }, [reactiveSubBlock, reactiveCond, credential?.type])
+ }, [credentialTypeById, reactiveSubBlocks, watchedCredentialIdsBySubBlock])
}
/**This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
...aceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.


Summary
Add google service support as a integration. Allows users with admin credentials to assume roles on behalf of their google workspace users.
Created new credential type
service_account.Added documentation to sim docs.
Type of Change
Testing
Checklist
Screenshots/Videos